Key

scroll

Key Blog

  • Key 主頁>
  • 博客>
  • [ue5] c++でactor poolを作成してパフォーマンスを最適化する方法
  • [UE5] 使用 C++建立 Actor Pool 來提升效能的實用方法

    @kiikey4(Key Zhao)

    [UE5] 使用 C++建立 Actor Pool 來提升效能的實用方法

    最後更新日期 2025年8月6日

    發佈日期 2025年8月5日

    0

    概述

    生成物件有機會非常耗費效能,尤其當生成數量很多時。為了解決這個問題,可以實作一個物件池(在 Unreal Engine 裡,使用 Actor Pool 較合適)。基本概念是,在載入時先預先生成 Actor,然後將它們停用並隱藏;當需要生成時,從池中取出並啟用;用完後,將 Actor 返回池中,而非直接銷毀,讓它能重複使用。

    Fab 上有許多物件池或 Actor Pool 插件,但實際上實作 Actor Pool 並不難,所以我決定自己寫一個。

    我這版本的 Actor Pool 支援多人連線,並且只在伺服器端運作,因為客戶端通常不會直接生成會同步的遊戲角色。

    📚 關於物件池的進一步閱讀

    開發環境

    • Unreal Engine 5.6.0
    • Windows 11 Pro

    主要內容

    一般生成效能測試

    在實作之前,我量度了生成投射物的效能,方便後續比較。

    ServerSpawnProjectile 的最大耗時是 (363.3 微秒)
    725_B4ActorPool_Max_2025-08-05_01h36_42_dfku1w

    如圖所示,在 SpawnActor (359.2 微秒) 中,引擎會執行 ConstructObject (78.8 微秒)RegisterAllComponents (122.9 微秒),這些可以透過使用 Actor Pool 避免,只需執行自訂邏輯的 BeginPlay

    💡 順帶一提,Blueprint Time (362.4 - 359.2 = 3.2 微秒) 這部分耗時,是因為 ServerSpawnProjectile 標記為 UFUNCTION(),導致引擎啟動 Blueprint 虛擬機處理,雖然它本質是 C++ 函數,這是多餘的開銷(引擎問題)。

    可以看到,生成 Actor 時有很多初始化步驟,會影響效能。

    以下是 Actor Pool 中生成與回收 Actor 的流程圖。

    flowchart_hk_pr0sr5

    用 C++ 建立 Actor Pool

    為可池化的 Actor 建立 IPoolableInterface

    我們會建立一個介面,當 Actor 從池中啟用或被停用返回池時會被呼叫,方便我們實作自訂邏輯。由於池中的 Actor 不會觸發 BeginPlayEndPlay,因此需要利用這個介面來手動管理生命週期行為。

    title=YourProject/Core/Interface/IPoolableInterface.h
    1#pragma once 2 3#include "CoreMinimal.h" 4#include "UObject/Interface.h" 5#include "IPoolableInterface.generated.h" 6 7class UActorPool; 8 9UINTERFACE(MinimalAPI) 10class UPoolableInterface : public UInterface 11{ 12 GENERATED_BODY() 13}; 14 15/** 16 * Interface for actors that can be managed by the actor pool system. 17 * Provides callbacks for when actors are activated from or returned to the pool. 18 */ 19class YOUR_API IPoolableInterface 20{ 21 GENERATED_BODY() 22 23public: 24 /** 25 * Called when an actor is retrieved from the pool and activated for gameplay. 26 * Use this to start timers, initialize state, and prepare for active use. 27 * @param InActorPool The pool this actor belongs to 28 * @param Location The world location to spawn at 29 * @param Rotation The world rotation to spawn with 30 * @param SpawnParameters Additional spawn parameters 31 */ 32 virtual void OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, const FActorSpawnParameters& SpawnParameters) = 0; 33 34 /** 35 * Called when an actor is being returned to the pool and deactivated. 36 * Use this to clear timers, reset state, and prepare for pool storage. 37 */ 38 virtual void OnDeactivateFromPool() = 0; 39};

    建立 ActorPool 類別

    我們使用 UObject 來實作這個池。雖然很多開發者和插件會使用集中管理的系統,例如管理器(managers)、子系統(subsystems)或只能附加到 ActorActorComponent,但我偏好使用 UObject,因為它更具彈性。

    基於 UObject 的池可以被任何類別擁有和管理,例如 Actor、遊戲模式(GameMode)、子系統(Subsystem)或元件(Component),因此可以很容易地整合到需要的地方。

    這種分散式的做法讓設計保持簡潔,更容易除錯,也更容易配置(例如設定預熱數量)。它同時具有更鬆耦合的結構,促進重用,相較於基於 Actor 的元件或全域子系統,能減少額外負擔。

    標頭檔案:

    YourProject/Core/Utility/Object/ActorPool.h
    1 2#pragma once 3 4#include "CoreMinimal.h" 5#include "Engine/World.h" 6#include "UObject/Object.h" 7#include "ActorPool.generated.h" 8 9/** 10 * 11 */ 12UCLASS() 13class YOUR_API UActorPool : public UObject 14{ 15 GENERATED_BODY() 16 17public: 18 UFUNCTION(BlueprintCallable, Category = "Actor Pool") 19 void InitializePool(TSubclassOf<AActor> InActorClass, int32 InPrewarmCount = 5); 20 21 UFUNCTION(BlueprintCallable, Category = "Actor Pool") 22 void ReturnToPool(AActor* Actor); 23 24public: 25 AActor* TrySpawnPooledActor(const FVector& Location, const FRotator& Rotation, 26 const FActorSpawnParameters& SpawnParameters = FActorSpawnParameters()); 27 28 FORCEINLINE bool IsEmpty() const 29 { 30 return PooledActors.Num() == 0; 31 } 32 33 FORCEINLINE int32 GetSize() const 34 { 35 return PooledActors.Num(); 36 } 37 38 FORCEINLINE void PushActor(AActor* Actor); 39 40 AActor* PopActor(); 41 42protected: 43 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool") 44 TSubclassOf<AActor> ActorClass; 45 46 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool", meta = (ClampMin = "1")) 47 int32 PrewarmCount = 5; 48 49 UPROPERTY() 50 TArray<TObjectPtr<AActor>> PooledActors; 51 52private: 53 void PrewarmPool(); 54 void ActivatePooledActor(AActor* Actor, const FVector& Location, const FRotator& Rotation, 55 const FActorSpawnParameters& SpawnParameters); 56 void DeactivatePooledActor(AActor* Actor); 57 58private: 59 // Stats 60 int32 PoolMisses = 0; 61}; 62

    💡 重要提示:務必為 PooledActors 加上 UPROPERTY()。否則,Unreal 的垃圾回收器可能會意外移除這些 Actor,導致難以排查的問題 😨。

    cpp 檔案:

    YourProject/Core/Utility/Object/ActorPool.cpp
    1 2 3#include "ActorPool.h" 4 5#include <YourProject/Core/Interface/IPoolableInterface.h> 6 7DEFINE_LOG_CATEGORY_STATIC(LogActorPool, Log, All); 8 9void UActorPool::InitializePool(TSubclassOf<AActor> InActorClass, int32 InPrewarmCount) 10{ 11 if (!IsValid(InActorClass)) 12 { 13 UE_LOG(LogActorPool, Error, TEXT("UActorPool::InitializePool - Invalid Actor Class")); 14 return; 15 } 16 17 ActorClass = InActorClass; 18 PrewarmCount = InPrewarmCount; 19 PooledActors.Empty(); 20 PrewarmPool(); 21} 22 23void UActorPool::PrewarmPool() 24{ 25 if (!IsValid(ActorClass) || PrewarmCount <= 0) 26 { 27 UE_LOG(LogActorPool, Warning, TEXT("UActorPool::PrewarmPool - Invalid Actor Class or Prewarm Count")); 28 return; 29 } 30 31 UWorld* World = GetWorld(); 32 if (!IsValid(World)) 33 { 34 UE_LOG(LogActorPool, Error, TEXT("UActorPool::PrewarmPool - Invalid World")); 35 return; 36 } 37 for (int32 i = 0; i < PrewarmCount; ++i) 38 { 39 FActorSpawnParameters SpawnParams; 40 SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; 41 42 AActor* NewActor = World->SpawnActor<AActor>(ActorClass, FVector::ZeroVector, FRotator::ZeroRotator, 43 SpawnParams); 44 if (IsValid(NewActor)) 45 { 46 DeactivatePooledActor(NewActor); 47 PushActor(NewActor); 48 UE_LOG(LogActorPool, Log, TEXT("Prewarmed actor %s (%d/%d)"), 49 *NewActor->GetName(), i + 1, PrewarmCount); 50 } 51 else 52 { 53 UE_LOG(LogActorPool, Error, TEXT("UActorPool::PrewarmPool - Failed to spawn actor %s"), 54 *ActorClass->GetName()); 55 } 56 } 57} 58 59AActor* UActorPool::TrySpawnPooledActor(const FVector& Location, const FRotator& Rotation, 60 const FActorSpawnParameters& SpawnParameters) 61{ 62 if (!IsValid(ActorClass) || PrewarmCount <= 0) 63 { 64 UE_LOG(LogActorPool, Warning, 65 TEXT("UActorPool::TrySpawnPooledActor - Invalid Actor Class or Prewarm Count")); 66 return nullptr; 67 } 68 69 70 UWorld* World = GetWorld(); 71 if (!IsValid(World)) 72 { 73 UE_LOG(LogActorPool, Warning, TEXT("TrySpawnPooledActor: Invalid World")); 74 return nullptr; 75 } 76 77 if (AActor* PooledActor = PopActor()) 78 { 79 ActivatePooledActor(PooledActor, Location, Rotation, SpawnParameters); 80 UE_LOG(LogActorPool, Log, TEXT("Spawned pooled actor: %s at location: %s, rotation: %s"), 81 *PooledActor->GetName(), *Location.ToString(), *Rotation.ToString()); 82 return PooledActor; 83 } 84 85 PoolMisses++; 86 UE_LOG(LogActorPool, Warning, 87 TEXT("Pool empty for class: %s, falling back to spawn new actor. Pool Misses: %d, Prewarm Count: %d"), 88 *ActorClass->GetName(), PoolMisses, PrewarmCount); 89 90 AActor* NewActor = World->SpawnActor<AActor>(ActorClass, Location, Rotation, SpawnParameters); 91 if (IsValid(NewActor)) 92 { 93 ActivatePooledActor(NewActor, Location, Rotation, SpawnParameters); 94 } 95 return NewActor; 96} 97 98void UActorPool::ReturnToPool(AActor* Actor) 99{ 100 if (!IsValid(Actor)) 101 { 102 return; 103 } 104 105 // Check authority for network safety 106 if (Actor->GetLocalRole() != ROLE_Authority) 107 { 108 UE_LOG(LogActorPool, Error, TEXT("Not Authority, cannot return actor to pool: %s"), *Actor->GetName()); 109 return; 110 } 111 112 DeactivatePooledActor(Actor); 113 PushActor(Actor); 114} 115 116void UActorPool::ActivatePooledActor(AActor* Actor, const FVector& Location, const FRotator& Rotation, 117 const FActorSpawnParameters& SpawnParameters) 118{ 119 if (!IsValid(Actor)) 120 { 121 return; 122 } 123 124 // Cache root component lookup to avoid repeated virtual calls 125 UPrimitiveComponent* RootPrimitive = Cast<UPrimitiveComponent>(Actor->GetRootComponent()); 126 127 // Batch actor transform and ownership changes 128 Actor->SetActorLocationAndRotation(Location, Rotation); 129 Actor->SetOwner(SpawnParameters.Owner); 130 Actor->SetInstigator(SpawnParameters.Instigator); 131 132 // Batch actor state changes 133 Actor->SetActorHiddenInGame(false); 134 Actor->SetActorEnableCollision(true); 135 Actor->SetActorTickEnabled(true); 136 137 // Reset physics state if primitive component exists 138 if (RootPrimitive) 139 { 140 RootPrimitive->SetAllPhysicsLinearVelocity(FVector::ZeroVector); 141 RootPrimitive->SetAllPhysicsAngularVelocityInDegrees(FVector::ZeroVector); 142 } 143 144 Actor->Reset(); 145 146 // Call poolable interface if implemented 147 if (IPoolableInterface* PoolableInterface = Cast<IPoolableInterface>(Actor)) 148 { 149 PoolableInterface->OnActivateFromPool(this, Location, Rotation, SpawnParameters); 150 } 151} 152 153void UActorPool::DeactivatePooledActor(AActor* Actor) 154{ 155 // Call poolable interface first to allow cleanup before state changes 156 if (IPoolableInterface* PoolableInterface = Cast<IPoolableInterface>(Actor)) 157 { 158 PoolableInterface->OnDeactivateFromPool(); 159 } 160 161 // Cache root component lookup to avoid repeated virtual calls 162 UPrimitiveComponent* RootPrimitive = Cast<UPrimitiveComponent>(Actor->GetRootComponent()); 163 164 // Batch actor state changes 165 Actor->SetActorHiddenInGame(true); 166 Actor->SetActorEnableCollision(false); 167 Actor->SetActorTickEnabled(false); 168 169 // Reset physics state if primitive component exists 170 if (RootPrimitive) 171 { 172 RootPrimitive->SetAllPhysicsLinearVelocity(FVector::ZeroVector); 173 RootPrimitive->SetAllPhysicsAngularVelocityInDegrees(FVector::ZeroVector); 174 } 175 176 // Clear ownership references 177 Actor->SetOwner(nullptr); 178 Actor->SetInstigator(nullptr); 179} 180 181void UActorPool::PushActor(AActor* Actor) 182{ 183 PooledActors.Add(Actor); 184} 185 186AActor* UActorPool::PopActor() 187{ 188 while (IsEmpty() == false) 189 { 190 if (AActor* Actor = PooledActors.Pop(); IsValid(Actor)) 191 { 192 return Actor; 193 } 194 } 195 return nullptr; 196}

    如果池中沒有足夠的 Actor,系統會退回使用正常生成方式,並且動態擴充池的大小。它同時會統計池缺失次數並記錄警告,方便我們調整預熱池的大小。

    使用範例

    在你的可池化 Actor 中實作此介面,並在 OnActivateFromPool()OnDeactivateFromPool() 中加入自訂邏輯。

    例如,我有一個投射物類別,它持有一個指向自身 ActorPool 的指標,讓它能夠回傳自己到池中,而不是被銷毀。

    標頭檔案:

    ProjectileBase.h
    1 2// ... 3 4public: 5// IPoolableInterface 6 virtual void OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, const FActorSpawnParameters& SpawnParameters) override; 7 virtual void OnDeactivateFromPool() override; 8 9 void ReturnToPoolOrDestroy(); 10 11protected: 12UPROPERTY() 13 TObjectPtr<UActorPool> ActorPool;
    ProjectileBase.cpp
    1 2void AProjectileBase::OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, 3 const FActorSpawnParameters& SpawnParameters) 4{ 5 ActorPool = InActorPool; 6 7 // Recalculate projectile velocity based on rotation 8 if (UProjectileMovementComponent* MovementComp = GetProjectileMovement()) 9 { 10 // Ensure the movement component has the correct UpdatedComponent 11 if (CollisionComp) 12 { 13 MovementComp->SetUpdatedComponent(CollisionComp); 14 } 15 16 // Calculate velocity based on spawn rotation, not actor forward (which might be wrong for pooled actors) 17 FVector InitialVelocity = Rotation.Vector() * MovementComp->InitialSpeed; 18 19 MovementComp->StartSimulating(InitialVelocity); 20 } 21 else 22 { 23 UE_LOG(LogTemp, Error, TEXT("OnPoolActivate - No movement component found!")); 24 } 25} 26 27void AProjectileBase::OnDeactivateFromPool() 28{ 29 if (UProjectileMovementComponent* MovementComp = GetProjectileMovement()) 30 { 31 MovementComp->StopSimulating(FHitResult()); 32 33 MovementComp->UpdateComponentVelocity(); 34 } 35} 36 37 38void AProjectileBase::ReturnToPoolOrDestroy() 39{ 40 if (!HasAuthority()) 41 { 42 return; 43 } 44 45 if (!IsValid(ActorPool)) 46 { 47 UE_LOG(LogTemp, Warning, TEXT("AProjectileBase::ReturnToPoolOrDestroy - No ActorPool set! Destroying actor instead.")); 48 Destroy(); 49 return; 50 } 51 52 ActorPool->ReturnToPool(this); 53 54}

    我會呼叫 ReturnToPoolOrDestroy() 來取代 Destroy()

    在負責生成可池化 Actor 的類別中,於標頭檔宣告 ActorPool,並且儲存要生成的可池化 Actor 類別。

    YourClass.h
    1class UActorPool; 2 3//... 4{ 5 6//... 7 8protected: 9 10//... 11 UPROPERTY(EditDefaultsOnly, Category=Projectile) 12 TSubclassOf<class APawProjectileBase> ProjectileClass; 13 14 UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Actor Pool") 15 TObjectPtr<UActorPool> ProjectilePool; 16 17 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool", meta = (ClampMin = "1")) 18 int32 PrewarmCount = 3; 19}

    初始化 Actor Pool,將類別和預熱數量傳入池中。

    YourClass.cpp
    1 2// can be during your custom Game Loading 3// or just put in BeginPlay() 4ProjectilePool->InitializePool(ProjectileClass, PrewarmCount); 5// ...

    生成池中已存在的 Actor

    YourClass.cpp
    1// ... 2if (const UWorld* World = GetWorld(); IsValid(World)) 3 { 4 //Set Spawn Collision Handling Override 5 FActorSpawnParameters ActorSpawnParams; 6 7 // Specify the spawn params 8 9 // ActorSpawnParams.SpawnCollisionHandlingOverride = 10 // ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; 11 // ActorSpawnParams.Owner = FPSPlayer; 12 // ActorSpawnParams.Instigator = FPSPlayer; 13 AActor* SpawnProjectile = ProjectilePool->TrySpawnPooledActor( 14 SpawnLocation, SpawnRotation, ActorSpawnParams); 15 } 16//...

    完成了!

    這個 Actor Pool 也適用於池化其他遊戲物件,例如 AI 敵人、拾取物品,或任何 Actor。

    結果

    使用 Actor Pool 前

    ServerSpawnProjectile 最大時間為 363.3 微秒
    725_B4ActorPool_Max_2025-08-05_01h36_42_dfku1w

    使用 Actor Pool 後

    ServerSpawnProjectile 最大時間為 187.2 微秒
    725_AfterActorPool_Max_2025-08-05_01h36_42_adqvro

    透過 Actor Pool 避免了 ConstructObjectRegisterAllComponents 的開銷,我也將 BeginPlay 的邏輯移到 OnActivateFromPool,且只需執行該部分。

    指標使用前 (B4)使用後 (AFT)改善幅度
    最小時間285.6 µs99.2 µs約 2.88 倍加速
    最大時間363.3 µs187.2 µs約 1.94 倍加速

    使用 Actor Pool 前,生成投射物耗時約 285.6–363.3 微秒
    實作 Actor Pool 後,啟動池中投射物只需 99.2–187.2 微秒
    較重的生成成本已在預熱階段完成,實際執行時可達到約 1.94× 到 2.88× 的速度提升。

    結論

    在 Unreal Engine 5 中以 C++ 使用 Actor Pool(物件池)是一種強大的方法,可以降低執行時負擔並提升效能,尤其適合頻繁生成及銷毀的 Actor,例如投射物、特效或敵人。使用 UObject 來實作池子,可帶來彈性、鬆耦合及更佳的重用性,讓遊戲架構更穩健。

    不論你是從遊戲實例(Game Instance)、遊戲模式(Game Mode)、子系統(Subsystem)、Actor 還是元件(Component)來管理池子,這種分散式設計都簡潔、易於除錯且高度可自訂。

    如果有任何錯誤,歡迎在評論中指正。

    參考資料

    0

    評論

    沒有評論

    發表閣下的感受